Anglit distribution#
The anglit distribution is a bounded, symmetric continuous distribution with a cosine-shaped density on a finite interval.
A useful characterization is:
Equivalently, if \(U \sim \mathrm{Uniform}(0,1)\) then
has the standard anglit distribution.
Learning goals#
write down the PDF/CDF (including SciPy’s
loc/scaleform)derive mean, variance, MGF/characteristic function, and entropy
implement inverse-CDF sampling with NumPy only
use
scipy.stats.anglitfor evaluation, sampling, and fitting
import numpy as np
import plotly.graph_objects as go
import os
import plotly.io as pio
from plotly.subplots import make_subplots
from scipy import stats
from scipy.stats import anglit as anglit_sp
pio.templates.default = "plotly_white"
pio.renderers.default = os.environ.get("PLOTLY_RENDERER", "notebook")
np.set_printoptions(precision=6, suppress=True)
rng = np.random.default_rng(7)
PI = np.pi
A = PI / 4 # standard support bound
1) Title & classification#
Name:
anglitType: continuous distribution
Standard support: \(x \in \left[-\tfrac{\pi}{4},\,\tfrac{\pi}{4}\right]\)
Parameter space (SciPy location–scale form):
location: \(\mathrm{loc} \in \mathbb{R}\)
scale: \(\mathrm{scale} > 0\)
Support with
loc/scale: $\(x \in \left[\mathrm{loc} - \tfrac{\pi}{4}\,\mathrm{scale},\ \mathrm{loc} + \tfrac{\pi}{4}\,\mathrm{scale}\right].\)$
2) Intuition & motivation#
What it models#
The standard anglit density is
So it is:
symmetric around 0 (a natural model for centered angular error)
bounded (no probability outside \([-\pi/4,\pi/4]\))
peaked at 0 and smoothly goes to 0 at the boundaries
A “uniform-through-a-sine” view#
Because the CDF has a sine in it, a clean intuition is the transformation:
This makes anglit useful as a bounded alternative to Gaussian noise when you want:
symmetry around a location
finite support
a smooth density that vanishes at the edges
Typical use cases#
In practice, anglit is not as common as Normal/Uniform/Von Mises for angles, but it can be a handy choice when your domain knowledge says angles cannot exceed a hard limit (e.g., mechanical tolerances, limited field-of-view jitter) and you want a smooth, unimodal shape.
Relations to other distributions#
Location–scale family: if \(Y\) is standard anglit then \(X=\mathrm{loc}+\mathrm{scale}\,Y\) is the general form used in SciPy.
Inverse-CDF sampling is closed form because the CDF is a sine.
Bounded symmetric noise: compared to a truncated Normal, anglit has a particularly simple PDF/CDF and exact inverse CDF.
def anglit_pdf(x, loc=0.0, scale=1.0):
"""Anglit PDF in SciPy's loc/scale parameterization (NumPy-only)."""
if not (scale > 0):
raise ValueError("scale must be > 0")
x = np.asarray(x, dtype=float)
z = (x - loc) / scale
out = np.zeros_like(z, dtype=float)
mask = (z >= -A) & (z <= A)
out[mask] = np.cos(2 * z[mask]) / scale
return out
def anglit_cdf(x, loc=0.0, scale=1.0):
"""Anglit CDF in SciPy's loc/scale parameterization (NumPy-only)."""
if not (scale > 0):
raise ValueError("scale must be > 0")
x = np.asarray(x, dtype=float)
z = (x - loc) / scale
out = np.zeros_like(z, dtype=float)
out[z >= A] = 1.0
inner = (z > -A) & (z < A)
out[inner] = 0.5 * (np.sin(2 * z[inner]) + 1.0)
return out
def anglit_ppf(u, loc=0.0, scale=1.0):
"""Inverse CDF (percent point function) for u in [0, 1] (NumPy-only)."""
if not (scale > 0):
raise ValueError("scale must be > 0")
u = np.asarray(u, dtype=float)
if np.any((u < 0) | (u > 1)):
raise ValueError("u must be in [0, 1]")
return loc + scale * 0.5 * np.arcsin(2 * u - 1)
x_grid = np.linspace(-A, A, 600)
fig = make_subplots(rows=1, cols=2, subplot_titles=["PDF (standard)", "CDF (standard)"])
fig.add_trace(go.Scatter(x=x_grid, y=anglit_pdf(x_grid), mode="lines", name="pdf"), row=1, col=1)
fig.add_trace(go.Scatter(x=x_grid, y=anglit_cdf(x_grid), mode="lines", name="cdf"), row=1, col=2)
fig.update_xaxes(title_text="x", row=1, col=1)
fig.update_xaxes(title_text="x", row=1, col=2)
fig.update_yaxes(title_text="density", row=1, col=1)
fig.update_yaxes(title_text="probability", row=1, col=2)
fig.update_layout(width=950, height=380, showlegend=False)
fig.show()
3) Formal definition#
Standard form#
Support: \(x \in [-\pi/4,\pi/4]\).
PDF $\( f(x) = \cos(2x)\,\mathbf{1}\{-\pi/4 \le x \le \pi/4\}. \)$
CDF $\( F(x)=\begin{cases} 0, & x < -\pi/4,\\ \tfrac12\bigl(\sin(2x)+1\bigr), & -\pi/4 \le x \le \pi/4,\\ 1, & x > \pi/4. \end{cases} \)$
Location–scale form (SciPy)#
If \(Y\) is standard anglit and \(X = \mathrm{loc}+\mathrm{scale}\,Y\) with \(\mathrm{scale}>0\), then
and
4) Moments & properties#
Because the support is finite and the PDF is smooth, all moments exist.
Mean and variance (standard)#
Symmetry gives \(\mathbb{E}[X]=0\).
A closed form for the variance is:
Skewness and kurtosis#
Skewness: \(0\) (symmetry)
Fourth moment: $\(\mathbb{E}[X^4] = \frac{\pi^4}{256} - \frac{3\pi^2}{16} + \frac{3}{2}\)$
Kurtosis: $\(\kappa = \frac{\mathbb{E}[X^4]}{\mathrm{Var}(X)^2},\qquad \kappa_{\mathrm{excess}}=\kappa-3.\)$
MGF and characteristic function#
For the standard distribution,
The characteristic function is
with removable singularities at \(t=\pm 2\) (the limit exists).
Entropy#
The differential entropy of the standard distribution is
For the location–scale form, \(h(\mathrm{loc}+\mathrm{scale}Y)=h(Y)+\log(\mathrm{scale})\).
# Closed-form moments/properties (standard)
mean_closed = 0.0
var_closed = PI**2 / 16 - 0.5
m4_closed = PI**4 / 256 - 3 * PI**2 / 16 + 1.5
kurt_closed = m4_closed / var_closed**2
excess_closed = kurt_closed - 3
entropy_closed = 1 - np.log(2)
mean_scipy, var_scipy, skew_scipy, kurt_excess_scipy = anglit_sp.stats(moments="mvsk")
entropy_scipy = anglit_sp.entropy()
print('mean (closed) :', mean_closed)
print('mean (SciPy) :', float(mean_scipy))
print('var (closed) :', var_closed)
print('var (SciPy) :', float(var_scipy))
print('skew (SciPy) :', float(skew_scipy))
print('kurtosis excess (closed):', excess_closed)
print('kurtosis excess (SciPy) :', float(kurt_excess_scipy))
print('entropy (closed):', entropy_closed)
print('entropy (SciPy) :', float(entropy_scipy))
mean (closed) : 0.0
mean (SciPy) : 0.0
var (closed) : 0.11685027506808487
var (SciPy) : 0.11685027506808487
skew (SciPy) : 0.0
kurtosis excess (closed): -0.8062497699541868
kurtosis excess (SciPy) : -0.8062497699541786
entropy (closed): 0.3068528194400547
entropy (SciPy) : 0.3068528194400547
5) Parameter interpretation#
SciPy exposes anglit as a location–scale family:
locshifts the distribution left/right (mean/median/mode all move toloc).scalestretches the support and rescales the density height by \(1/\mathrm{scale}\).The shape as a function of the standardized variable \(z=(x-\mathrm{loc})/\mathrm{scale}\) stays the same: \(\cos(2z)\) on \([-\pi/4,\pi/4]\).
loc = 0.5
scales = [0.4, 0.8, 1.4]
x = np.linspace(loc - max(scales) * A, loc + max(scales) * A, 800)
fig = go.Figure()
for s in scales:
fig.add_trace(go.Scatter(x=x, y=anglit_pdf(x, loc=loc, scale=s), mode="lines", name=f"scale={s}"))
fig.update_layout(
title="Anglit PDF under different scales (loc fixed)",
xaxis_title="x",
yaxis_title="density",
width=900,
height=420,
)
fig.show()
6) Derivations#
Expectation#
For the standard distribution,
The integrand is an odd function (product of odd \(x\) and even \(\cos(2x)\)), so the integral over a symmetric interval is 0.
Variance#
Since \(\mathbb{E}[X]=0\),
Use evenness to write it as \(2\int_0^{\pi/4} x^2\cos(2x)\,dx\) and integrate by parts:
Evaluate from \(0\) to \(\pi/4\) (where \(\sin(\pi/2)=1\) and \(\cos(\pi/2)=0\)) to get
Likelihood (i.i.d. sample)#
For observations \(x_1,\dots,x_n\) and parameters \((\mathrm{loc},\mathrm{scale})\) with \(\mathrm{scale}>0\),
with the support constraint \(\left|\tfrac{x_i-\mathrm{loc}}{\mathrm{scale}}\right|\le \pi/4\) for all \(i\) (otherwise the likelihood is 0).
This log-likelihood is smooth inside the feasible region but goes to \(-\infty\) when any sample approaches the boundary (because \(\cos(2z)\to 0\) as \(|z|\to\pi/4\)).
# Quick numerical sanity check of mean/variance via a fine grid
x = np.linspace(-A, A, 400_001)
pdf = anglit_pdf(x)
dx = x[1] - x[0]
mean_num = np.sum(x * pdf) * dx
var_num = np.sum((x - mean_num) ** 2 * pdf) * dx
print('mean (grid integral):', mean_num)
print('var (grid integral):', var_num)
print('var (closed) :', var_closed)
mean (grid integral): -3.3929984748491564e-17
var (grid integral): 0.1168502750644443
var (closed) : 0.11685027506808487
7) Sampling & simulation (NumPy only)#
Because the CDF is explicit,
we can sample by inverse transform sampling:
Draw \(U \sim \mathrm{Uniform}(0,1)\).
Solve \(U = \tfrac12(\sin(2X)+1)\): $\(\sin(2X) = 2U-1\ \Rightarrow\ 2X = \arcsin(2U-1)\ \Rightarrow\ X = \tfrac12\arcsin(2U-1).\)$
Apply
loc/scaleif desired: \(X_{\mathrm{ls}} = \mathrm{loc}+\mathrm{scale}\,X\).
This is numerically stable as long as we avoid passing values outside \([-1,1]\) into arcsin (so clamp or validate if you construct \(U\) in unusual ways).
def sample_anglit(size, loc=0.0, scale=1.0, rng=None):
"""Sample from anglit(loc, scale) using inverse CDF (NumPy-only)."""
if rng is None:
rng = np.random.default_rng()
u = rng.uniform(0.0, 1.0, size=size)
return anglit_ppf(u, loc=loc, scale=scale)
n = 50_000
samples = sample_anglit(n, rng=rng)
# Transformation check: sin(2X) should look Uniform(-1,1)
z = np.sin(2 * samples)
print('samples mean ~', samples.mean())
print('samples var ~', samples.var())
print('closed-form var', var_closed)
print('z (sin(2X)) mean ~', z.mean())
print('z (sin(2X)) min/max:', z.min(), z.max())
samples mean ~ 0.0017822315715813274
samples var ~ 0.11693584576419093
closed-form var 0.11685027506808487
z (sin(2X)) mean ~ 0.002866540728205996
z (sin(2X)) min/max: -0.9999568082303634 0.9998657111910878
8) Visualization#
Below are:
the analytic PDF and CDF
a Monte Carlo histogram overlaid with the PDF
x_grid = np.linspace(-A, A, 800)
pdf_grid = anglit_pdf(x_grid)
cdf_grid = anglit_cdf(x_grid)
fig = make_subplots(
rows=1,
cols=3,
subplot_titles=["PDF", "CDF", "Samples (hist) + PDF"],
)
fig.add_trace(go.Scatter(x=x_grid, y=pdf_grid, mode="lines", name="pdf"), row=1, col=1)
fig.add_trace(go.Scatter(x=x_grid, y=cdf_grid, mode="lines", name="cdf"), row=1, col=2)
fig.add_trace(
go.Histogram(
x=samples,
nbinsx=60,
histnorm="probability density",
name="samples",
opacity=0.6,
),
row=1,
col=3,
)
fig.add_trace(go.Scatter(x=x_grid, y=pdf_grid, mode="lines", name="pdf"), row=1, col=3)
for c in [1, 2, 3]:
fig.update_xaxes(title_text="x", row=1, col=c)
fig.update_yaxes(title_text="density", row=1, col=1)
fig.update_yaxes(title_text="probability", row=1, col=2)
fig.update_yaxes(title_text="density", row=1, col=3)
fig.update_layout(width=1100, height=380, showlegend=False)
fig.show()
9) SciPy integration (scipy.stats.anglit)#
scipy.stats.anglit is the standardized distribution plus loc and scale.
Common methods:
anglit_sp.pdf(x, loc, scale)anglit_sp.cdf(x, loc, scale)anglit_sp.rvs(loc=..., scale=..., size=..., random_state=...)anglit_sp.fit(data)→ estimates(loc, scale)
# Match our NumPy-only PDF/CDF to SciPy
x = np.linspace(-A, A, 10)
print('max |pdf - scipy|:', np.max(np.abs(anglit_pdf(x) - anglit_sp.pdf(x))))
print('max |cdf - scipy|:', np.max(np.abs(anglit_cdf(x) - anglit_sp.cdf(x))))
# Demonstrate rvs + fit on location-scale data
loc_true, scale_true = 1.2, 0.7
data = anglit_sp.rvs(loc=loc_true, scale=scale_true, size=2000, random_state=rng)
loc_hat, scale_hat = anglit_sp.fit(data)
print('true loc/scale:', (loc_true, scale_true))
print('fit loc/scale:', (loc_hat, scale_hat))
# Visualize fitted vs true
x_grid = np.linspace(loc_true - scale_true * A, loc_true + scale_true * A, 600)
fig = go.Figure()
fig.add_trace(go.Histogram(x=data, nbinsx=60, histnorm='probability density', name='data', opacity=0.55))
fig.add_trace(go.Scatter(x=x_grid, y=anglit_sp.pdf(x_grid, loc=loc_true, scale=scale_true), mode='lines', name='true pdf'))
fig.add_trace(go.Scatter(x=x_grid, y=anglit_sp.pdf(x_grid, loc=loc_hat, scale=scale_hat), mode='lines', name='fit pdf'))
fig.update_layout(
title='SciPy anglit: fit() on synthetic location–scale data',
xaxis_title='x',
yaxis_title='density',
width=900,
height=420,
)
fig.show()
max |pdf - scipy|: 0.0
max |cdf - scipy|: 1.1102230246251565e-16
true loc/scale: (1.2, 0.7)
fit loc/scale: (1.2007715567009445, 0.7011704429753689)
10) Statistical use cases#
Hypothesis testing (goodness-of-fit)#
You might test whether a bounded, symmetric error distribution is better modeled as anglit than (say) Uniform or truncated Normal.
A common approach is a goodness-of-fit test like Kolmogorov–Smirnov (KS). Caution: if you estimate loc/scale from the data and then run a vanilla KS test, the p-value is not exact. A practical workaround is a parametric bootstrap that repeats the fitting step.
Bayesian modeling#
Anglit can be a reasonable likelihood/prior for an angular offset when:
the offset is centered around some location
locdeviations are bounded by \(\tfrac{\pi}{4}\,\mathrm{scale}\)
We’ll show a simple grid posterior for loc with known scale.
Generative modeling#
In simulation pipelines, anglit is a simple way to add bounded, smooth noise (e.g., small orientation jitter) with exact inverse-CDF sampling.
# Hypothesis testing: parametric bootstrap KS for fitted anglit
def ks_statistic_to_fitted_anglit(sample):
loc_hat, scale_hat = anglit_sp.fit(sample)
fitted = anglit_sp(loc=loc_hat, scale=scale_hat)
return stats.kstest(sample, fitted.cdf).statistic
n = 400
loc_true, scale_true = 0.2, 0.9
x_obs = anglit_sp.rvs(loc=loc_true, scale=scale_true, size=n, random_state=rng)
D_obs = ks_statistic_to_fitted_anglit(x_obs)
B = 250 # keep modest for notebook runtime
loc_hat, scale_hat = anglit_sp.fit(x_obs)
fitted = anglit_sp(loc=loc_hat, scale=scale_hat)
Ds = np.empty(B)
for b in range(B):
sim = fitted.rvs(size=n, random_state=rng)
Ds[b] = ks_statistic_to_fitted_anglit(sim)
p_boot = (np.sum(Ds >= D_obs) + 1) / (B + 1)
print('KS statistic (observed):', D_obs)
print('bootstrap p-value :', p_boot)
KS statistic (observed): 0.03853867853294057
bootstrap p-value : 0.4262948207171315
# Bayesian modeling: grid posterior for loc with known scale (and uniform prior)
def anglit_logpdf(x, loc=0.0, scale=1.0):
if not (scale > 0):
raise ValueError('scale must be > 0')
x = np.asarray(x, dtype=float)
z = (x - loc) / scale
out = np.full_like(z, -np.inf, dtype=float)
mask = (z >= -A) & (z <= A)
out[mask] = np.log(np.cos(2 * z[mask])) - np.log(scale)
return out
scale_known = 0.8
loc_true = -0.3
x_obs = anglit_sp.rvs(loc=loc_true, scale=scale_known, size=120, random_state=rng)
# Uniform prior over a plausible interval
grid = np.linspace(-1.5, 1.5, 1000)
loglike = np.array([anglit_logpdf(x_obs, loc=mu, scale=scale_known).sum() for mu in grid])
logpost = loglike - loglike.max() # stabilize
post = np.exp(logpost)
post /= np.trapz(post, grid)
mu_map = grid[np.argmax(post)]
fig = go.Figure()
fig.add_trace(go.Scatter(x=grid, y=post, mode='lines', name='posterior'))
fig.add_vline(x=loc_true, line_dash='dash', line_color='black', annotation_text='true loc')
fig.add_vline(x=mu_map, line_dash='dot', line_color='red', annotation_text='MAP')
fig.update_layout(
title='Posterior over loc (uniform prior, scale known)',
xaxis_title='loc',
yaxis_title='density',
width=900,
height=380,
)
fig.show()
# Generative modeling example: bounded angular jitter
def wrap_to_pi(angle):
"""Wrap angle to (-pi, pi]."""
return (angle + PI) % (2 * PI) - PI
m = 5000
theta = rng.uniform(-PI, PI, size=m) # latent direction
eps = sample_anglit(m, loc=0.0, scale=0.15, rng=rng) # bounded jitter
y = wrap_to_pi(theta + eps)
fig = make_subplots(rows=1, cols=2, subplot_titles=["Jitter ε", "Wrapped observation y = wrap(θ+ε)"])
fig.add_trace(go.Histogram(x=eps, nbinsx=60, histnorm='probability density', name='eps'), row=1, col=1)
fig.add_trace(go.Histogram(x=y, nbinsx=80, histnorm='probability density', name='y'), row=1, col=2)
fig.update_xaxes(title_text='ε', row=1, col=1)
fig.update_xaxes(title_text='y', row=1, col=2)
fig.update_yaxes(title_text='density', row=1, col=1)
fig.update_yaxes(title_text='density', row=1, col=2)
fig.update_layout(width=1050, height=380, showlegend=False)
fig.show()
11) Pitfalls#
Scale must be positive:
scale <= 0is invalid.Hard support constraint: values outside \([\mathrm{loc}-\tfrac{\pi}{4}\mathrm{scale},\ \mathrm{loc}+\tfrac{\pi}{4}\mathrm{scale}]\) have PDF 0 and log-PDF \(-\infty\).
Boundary behavior: the PDF goes to 0 at the endpoints, so
logpdfgoes to \(-\infty\); this can make optimization/fit sensitive if observations lie extremely close to the boundary.Inverse CDF edge cases: for \(u\) extremely close to 0 or 1, floating point roundoff can push \(2u-1\) slightly outside \([-1,1]\); clip if needed.
Goodness-of-fit with fitted parameters: if you fit
loc/scaleand then run a KS test, use a bootstrap (or another method) if you need calibrated p-values.
12) Summary#
Anglit is a continuous, bounded, symmetric distribution with PDF \(\cos(2x)\) on \([-\pi/4,\pi/4]\).
Its CDF is explicit, enabling exact inverse-CDF sampling: \(X=\tfrac12\arcsin(2U-1)\).
Key closed forms (standard):
mean \(0\)
variance \(\pi^2/16 - 1/2\)
entropy \(1-\log 2\) (nats)
MGF \(M(t)=\dfrac{4\cosh(\pi t/4)}{t^2+4}\)
In SciPy, use
loc/scalefor shifting and scaling:scipy.stats.anglit.